✅ソース更新時は、新しい検索結果を検索が終了するまでUIに反映させない (scrapbox-select-suggestion)
何もしていないのに急に検索候補数が少なくなる現象が起きてしまう
不自然なのでやめたい
2023-03-24
動作も問題なし
別途対処する
やりたいこと
query変更
現在の検索を中断し、新しい検索を開始する
source変更
現在の検索が終わったら、新しく検索し直す
タスクを保持するキューが必要
全部検索し終えてから、検索結果を差し替える
projects変更
並び替えを変えるだけ
enable変更
現在の検索を中断し、検索結果を空にする
[source, query]に依存する箇所がどうしても出てしまい、どうにもならない
一旦純粋函数で考え直したほうが良さそう
入力をquery、source、projectsに限定する
出力は{ title: string; projects: string[]}[]をasync iteratorで返せばいいかな
query変更後の検索は、少しずつ検索結果を返す
全部検索してから差し替える場合は、一度に配列として返す
状態を持たせたいので、classで作ったほうがよさそう
enablesはpreact側で管理する
classの使い方イメージ
code:hook.ts
export const userSearch = (query: string; source: Candidate[], projects: string[], enable: boolean) => {
const manager = useState(new Manager(query, source, projects)); useEffect(() => {
if (!enable) return;
manager.updateSource(source, projects);
useEffect(() => {
if (!enable) return;
manager.updateQuery(query);
useEffect(() => {
if (!enable) return;
// 戻り値に登録解除函数が返ってくる
return manager.addListener((links) => setLinks(links));
return links;
};
2023-01-06
21:11:00 案2
抽象化した検索処理
内部でUI threadで計算してようがweb workerを使っていようが外部とは関係ない
optionsでちょこっと関わりはあるが
queryもupdateSource()のような更新函数で更新し、filterそのものをasync iteratorにすることも考えたが、一度breakしてしまったら再開できなくなるのでやめた
code:ts
export const makeSearcher: <Item extends Source>(options?: SearchOptions) => {
/** 検索候補を更新する */
updateSource: (source: Iterable<Item>) => void;
/** 候補を絞り込む
*
* 検索が中断される条件
*
* - generatorがbreakされた
* - 別の場所でfilter()が呼び出された
* - この場合新しい検索処理が開始される
*
* @param query 検索語句
* @return 検索結果をchunkごとに返す
*/
filter: (query: string) => AsyncGenerator<SearchResult<Item>, void, unknown>;
}
export interface Source {
word: string;
}
export interface SearchResult<Item> {
items: Item[];
/** 一続きの処理を示すID
*
* 違うIDが返されたら、別の検索が始まったものとして前回のを上書きする
*/
id: number;
}
hooks
昨日考えたのよりシンプルになったかも
code:ts
export const useSearch = (
query: string,
source: Iterable<Item>,
opitons?: SearchOptions,
): Item[] => {
const { filter, updateSource } = useMemo(
() => makeSearcher<Item>(options),
);
useEffect(() => {
(async () => {
let currentId = NaN; // 絶対に被らないやつにしておく
for await (const { items, id } of filter(query)) {
if (currentId !== id) {
currentId = id;
setItems(items);
continue;
}
// 特に検索結果がなければ更新しない
if (items.length === 0) continue;
setItems(
);
}
})();
// 次回のfilter実行時に今のfilterは勝手に止まるため、終了フラグを作って終了処理させる必要はない
return items_;
};
2023-01-05
06:11:01 リベンジ
ソースの取得・更新と絞り込みを一つの函数にまとめる
検索結果をchunkごとにcallbackで返す
函数内部でソースを取得し、絞り込みをかける
2023-01-06 やっぱり外部から注入しよう
webworkerを使うことになっても、Transferableでsourceを転送すればいいから、大したコストではない webworker側からindexed DBに接続しなくてもいい
ソースが更新されたら、現在検索しているloop番号まで検索し直して一度に返し、再びchunkごとに計算してcallbackで返す
code:ts
export const search: (searchInit: SearchInit, onSearch: (result: SearchResult) => void) => () => void;
export interface SearchInit extends SearchOptions {
/** 検索語句 */
query: string;
/** 検索範囲のprojects */
projects: string[];
}
export interface SearchOptions {
/** 検索に使うworkerのURL
*
* 指定されていないときは、UI threadで検索する
*/
worker?: string | URL;
/** 一回で検索する候補の最大数
*
* @default workerありの場合は50000, workerなしの場合は5000
*/
chunk?: number;
}
export interface SearchResult {
/** 1 loopで得た検索結果 */
items: Item[];
/** 一回の検索処理に固有なID
*
* IDが違う場合は、新しい検索処理が走ったものとして、前回の検索結果を上書きする
*
* 実体はただの通し番号。前回の結果と同じ処理かどうかさえ区別できればいいので、overflowは気にしない
*/
id: number;
}
hook側では、queryが変わったときとproject listが順不同比較で変化したときのみ再検索する
project listの順番が変わっても、ソースは変化しないので無視する
検索するソースの優先順位には反映したいので、次回の検索でそれを反映する
code:hook.ts
export const useSearch(query: string, projects: string[], options: SearchOptions): Item[] => {
const projectsRef = useRef(projects);
/** 順不同比較でprojectsが変化したときにuseEffectを起動させるためのdummy object */
useEffect(() => {
if (!isSameAsSet(projectsRef.current, projects)) {
setProjectChanged({});
}
projectsRef.current = projects;
useEffect(() => {
let currentId = "";
return search(
{ query, projects: projectsRef.current, options },
({ items: chunk, id }) => {
if (id === currentId) {
return;
}
setItems(chunk);
currentId = id;
},
);
return items;
};
2022-11-12 12:49:27 やっぱりむずい
途中まで書いたやつをここに置いておく
型エラーがあるので注意
code:ts
import { Candidate, CandidateWithPoint, Filter, makeFilter } from "./search.ts";
import { logger } from "./debug.ts";
export interface IncrementalSearchOptions {
/** 一度に検索する候補の最大数
*
* @default 1000
*/
chunk?: number;
/** 検索結果を返す時間間隔 (単位はms)
*
* @default 500
*/
interval?: number;
}
/** 中断可能な検索メソッド */
export const incrementalSearch = (
query: string,
source: Candidate[],
listener: (candidates: CandidateWithPoint[]) => void,
options?: IncrementalSearchOptions,
): () => void => {
const chunk = options?.chunk ?? 1000;
const interval = options?.interval ?? 500;
let filter: Filter | undefined;
let cause: "querychange" | "sourcechange" = "querychange";
const listeners = new Set<(candidates: CandidateWithPoint[]) => void>();
let job: Promise<void> | undefined;
let terminated = 0;
let timer: number | undefined;
/** 検索中断を命令する */
const terminate = () => {
terminated = new Date().getTime();
clearTimeout(timer);
};
const dispatch = (candidates: CandidateWithPoint[]) => {
for (const listener of listeners) {
listener(candidates);
}
};
/** 検索語句を更新し、検索を実行する
*
* @param query 検索語句
* @return 検索が終了もしくは中断したときに解決するPromise
*/
const setQuery = async (query: string) => {
terminate();
filter = makeFilter(query);
cause = "querychange";
const candidates: CandidateWithPoint[] = [];
const update = () => {
dispatch(candidates);
timer = undefined;
};
for await (const results of search()) {
candidates.push(...results);
if (timer !== undefined) continue;
update();
timer = setTimeout(update, options?.interval ?? 500);
}
};
/** 検索候補を更新し、検索を実行する
*
* @param source 検索候補
* @return 検索が終了もしくは中断したときに解決するPromise
*/
const setSource = async (source: Candidate[]) => {
if (cause === "sourcechange") {
terminate();
}
filter = makeFilter(query);
cause = "querychange";
const candidates: CandidateWithPoint[] = [];
for await (const results of search()) {
candidates.push(...results);
}
dispatch(candidates);
};
async function* search() {
if (!filter) {
yield [];
return;
}
const total = Math.floor(source.length / chunk) + 1;
const started = new Date().getTime();
for (let i = 0; i < total; i++) {
// 検索中断命令を受け付けるためのinterval
await new Promise((resolve) => requestAnimationFrame(resolve));
if (terminated > started) return;
logger.time([${i}/${total - 1}] search for "${query}");
yield filter(source.slice(i * chunk, (i + 1) * chunk));
logger.timeEnd([${i}/${total - 1}] search for "${query}");
}
}
// 検索を中断させる
return () => {
terminate = true;
clearTimeout(timer);
};
};
2022-11-04 20:22:22 ややこしくなったので中断